初始化单例资源
初始化单例资源有很多方法,比如定义 package 级别的变量,这样程序在启动的时候就可以初始化:
1 | package abc |
或者在 init 函数中进行初始化:
1 | package abc |
又或者在 main 函数开始执行的时候,执行一个初始化的函数:
1 | package abc |
这三种方法都是线程安全的,并且后两种方法还可以根据传入的参数实现定制化的初始化操作。
延迟进行初始化
但是很多时候我们是要延迟进行初始化的,所以有时候单例资源的初始化,我们会使用下面的方法:
1 |
|
这种方式虽然实现起来简单,但是有性能问题。一旦连接创建好,每次请求的时候还是得竞争锁才能读取到这个连接,这是比较浪费资源的,因为连接如果创建好之后,其实就不需要锁的保护了。怎么办呢?
Once 使用场景
Once 可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。
sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值的函数。
1 | func (o *Once) Do(f func()) |
1 |
|
很多时候,还会将值和 Once 封装成一个新的数据结构,提供只初始化一次的值。
1 |
|
在官方的 proxy 中的实现也使用到了 once。掘金上也有人写了一些单例的例子。
总结一下 Once 并发原语解决的问题和使用场景:Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。
如何实现一个 Once
很多人认为实现一个 Once 一样的并发原语很简单,只需使用一个 flag 标记是否初始化过即可,最多是用 atomic 原子操作这个 flag,比如下面的实现:
1 |
|
这个实现有一个很大的问题,就是如果参数 f 执行很慢的话,后续调用 Do 方法的 goroutine 虽然看到 done 已经设置为执行过了,但是获取某些初始化资源的时候可能会得到空的资源,因为 f 还没有执行完。
以下为官方的实现。这第一次判断的时候没有上锁,然后在 doSlow 中进行了上锁的判断,并且进行了二次校验。这样做的原因是方便内联。
1 |
|
虽然代码中使用了互斥锁来保证 doSlow 函数只会被一个 goroutine 调用一次,但是可能存在这样的情况:当一个 goroutine 获取到锁后,另一个 goroutine 也同时获取到了锁,但是在第一个 goroutine 执行完 f() 函数之前,第二个 goroutine 也执行了 f() 函数。这就违反了 Once 只执行一次的要求。
为了避免这种情况发生,需要在加锁之后再次判断 done 的值,如果已经被设置为 1,那么就跳过执行 f(),否则继续执行 f() 并将 done 的值设置为 1。这就是所谓的“双检查锁定(double-checked locking)”机制,可以提高程序性能。
怎么可能会两个 goroutine 同时获取到锁呢?
在一般情况下,使用互斥锁确实可以保证同一时间只有一个 goroutine 能够获取到锁。但是在高并发场景下,因为 goroutine 的调度是由 Go 运行时进行管理的,并且无法精确控制每个 goroutine 的执行时间和顺序,所以就可能会出现多个 goroutine 同时尝试获取锁的情况。
具体来说,在第一个 goroutine 获取到锁之后,如果此时第二个 goroutine 也同时获取到锁,则它们将按照不确定的顺序执行,这就可能导致第二个 goroutine 在第一个 goroutine 执行完 f() 函数之前也执行了 f() 函数,从而违反了 Once 只执行一次的要求。
因此,为了防止这种情况的发生,需要在加锁之后再次检查 done 的值,以确保只有第一个获取到锁的 goroutine 才会执行 f() 函数。
这么说 Mutex 并不可靠?还是可能会出现多个 goroutine 同时获取锁的情况?
Mutex 是一种可靠的同步原语,它能够保证同一时间只有一个 goroutine 能够获取到锁。但是在高并发场景下,由于 goroutine 的调度是由 Go 运行时进行管理的,因此无法精确控制每个 goroutine 的执行时间和顺序,所以在极端情况下,仍然可能会出现多个 goroutine 同时竞争 Mutex 的情况。
不过需要注意的是,这种情况出现的概率非常小,并且只有在高并发、长时间运行的场景下才会出现。对于大部分应用程序来说,Mutex 的可靠性已经足够满足需求,并且通过一些常见的优化技巧(如避免锁嵌套、减小锁粒度等),可以进一步提高程序的性能和可靠性。
什么是内联?
Go语言中的内联(inlining)是一种编译器优化技术,它通过将函数调用处直接替换为函数体来减少函数调用的开销,从而提高代码的执行效率。在Go语言中,可以使用go:linkname指令强制将一个函数标记为内联函数。
内联可以减少函数调用时的栈操作和参数传递,但也会增加代码尺寸,因此需要权衡其优缺点。在Go语言中,编译器会根据函数的复杂度、调用次数等多个因素来决定是否对函数进行内联优化。
上述代码中的 doSlow 用到了内联了吗?
在这段代码中,并没有明确指定将
doSlow
函数内联化。实际上,在 Go 语言中,是否内联一个函数是由编译器来决定的,编译器会根据函数的大小、复杂度以及调用次数等多个因素来判断是否进行内联优化。如果编译器认为
doSlow
函数的规模适当并且被频繁调用,那么它很有可能会将其内联化,从而减少函数调用的开销。但如果编译器认为内联化不利于代码性能或者会增加代码大小,它就不会进行内联优化。因此,可以说这段代码中存在潜在的内联优化机会,但具体是否会进行内联需要看编译器的判断结果。
内联要满足什么条件?
在 Go 语言中,内联优化的条件包括:
- 函数体的大小适中:如果函数体太大,那么内联会导致代码膨胀,不利于性能和可维护性;如果函数体太小,则内联带来的好处也不明显。
- 函数的调用次数较多:内联只有在函数被频繁调用时才能发挥作用,如果函数很少被调用,则内联优化意义不大。
- 函数没有递归调用:递归调用本身就是一个非常复杂的过程,内联递归函数会带来大量的代码膨胀和性能问题。
- 函数没有指针引用或跨包调用:指针引用或跨包调用会增加内联处理的难度,因此编译器可能会放弃对这类函数进行内联优化。
需要注意的是,虽然内联技术可以提高代码的执行效率,但它也会导致代码体积增大,因此使用内联技术时需要权衡其优缺点。在 Go 语言中,编译器会自动判断是否对函数进行内联优化,开发者无需手动干预。但如果确实需要将某个函数进行强制内联,可以使用
go:inline
标记来实现。
举个例子示范下将某个函数进行强制内联?
当一个函数在某些场景下需要频繁调用时,可以使用内联技术来优化性能。如果希望强制将某个函数进行内联,可以使用
go:inline
标记。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 > package main
>
> import "fmt"
>
> // 将 add 函数标记为内联函数
> //go:inline
> func add(a, b int) int {
> return a + b
> }
>
> // 在 main 函数中多次调用 add 函数
> func main() {
> for i := 0; i < 10; i++ {
> sum := add(i, i+1)
> fmt.Println(sum)
> }
> }
>
在上面的代码中,我们将
add
函数标记为内联函数。在main
函数中,我们通过循环多次调用add
函数,这样就会触发编译器对add
函数进行内联优化。由于该函数体非常简单,内联处理不会导致代码膨胀,反而能够提高代码的执行效率。需要注意的是,内联并不总是适合所有情况,应该根据具体的场景来选择是否使用内联技术。在实际应用中,开发者一般不需要手动指定内联函数,编译器会自动判断是否对函数进行内联优化。
Once 常见使用错误场景
死锁
Do 方法会执行一次 f,但是如果 f 中再次调用这个 Once 的 Do 方法的话,就会导致死锁的情况出现。
资源未初始化
如果 f 方法执行的时候 panic,或者 f 执行初始化资源的时候失败了,这个时候,Once 还是会认为初次执行已经成功了,即使再次调用 Do 方法,也不会再次执行 f。
我们可以自己实现一个类似 Once 的并发原语,既可以返回当前调用 Do 方法是否正确完成,还可以在初始化失败后调用 Do 方法再次尝试初始化,直到初始化成功才不再初始化了。
1 |
|
甚至我们可以拓展 sync.Once,来实现查询是否已初始化过了。
1 |
|